# SPDX-License-Identifier: MIT
"""Parser for PMake manifest files."""

from __future__ import annotations

import re
from collections import defaultdict
from pathlib import Path
from typing import Dict, Iterable, List, Set

from .model import Manifest, ProjectSection, Target


class ManifestError(RuntimeError):
    """Raised when the manifest is invalid."""


SECTION_PATTERN = re.compile(r"^\[(?P<section>[A-Za-z0-9_-]+)\]\s*$")
ASSIGN_PATTERN = re.compile(r"^(?P<key>[A-Za-z0-9_-]+)\s*=\s*(?P<value>.+)$")


def parse_manifest(path: Path) -> Manifest:
    text = path.read_text(encoding="utf-8")
    lines = text.splitlines()

    current_section = None
    project_entries: Dict[str, str] = {}
    target_lines: List[str] = []
    extern_entries: Dict[str, str] = {}
    proposal_lines: List[str] = []
    roadmap_lines: List[str] = []

    for raw in lines:
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        m = SECTION_PATTERN.match(line)
        if m:
            current_section = m.group("section").lower()
            continue
        if current_section is None:
            raise ManifestError(f"Line outside of any section: {raw!r}")
        if current_section == "project":
            match = ASSIGN_PATTERN.match(line)
            if not match:
                raise ManifestError(f"Invalid project assignment: {raw!r}")
            key = match.group("key").lower()
            value = match.group("value").strip()
            project_entries[key] = value
        elif current_section == "targets":
            target_lines.append(line)
        elif current_section == "extern":
            match = ASSIGN_PATTERN.match(line)
            if not match:
                raise ManifestError(f"Invalid extern assignment: {raw!r}")
            alias = match.group("key").strip()
            extern_entries[alias] = match.group("value").strip()
        elif current_section == "proposal":
            proposal_lines.append(raw.rstrip())
        elif current_section == "roadmap":
            roadmap_lines.append(raw.rstrip())
        else:
            raise ManifestError(f"Unknown section [{current_section}]")

    required_sections = {"project", "targets"}
    seen_sections = set()
    for line in lines:
        m = SECTION_PATTERN.match(line.strip())
        if m:
            seen_sections.add(m.group("section").lower())
    missing = required_sections - seen_sections
    if missing:
        raise ManifestError(f"Missing required sections: {', '.join(sorted(missing))}")

    project = _build_project(path.parent, project_entries)
    targets = _build_targets(path.parent, target_lines)
    _validate_targets(targets)

    proposal_text = "\n".join(proposal_lines).strip()
    roadmap_text = "\n".join(roadmap_lines).strip()

    return Manifest(
        project=project,
        targets=targets,
        extern=extern_entries,
        proposal=proposal_text,
        roadmap=roadmap_text,
        manifest_path=path,
    )


def _build_project(root: Path, entries: Dict[str, str]) -> ProjectSection:
    if "name" not in entries:
        raise ManifestError("project.name is required")
    name = entries["name"].strip()
    if not name:
        raise ManifestError("project.name cannot be empty")

    version = entries.get("version")
    store_dir = entries.get("store_dir") or f"logs/{name}"

    globals_raw = entries.get("globals", "")
    globals_list: List[Path] = []
    if globals_raw:
        for item in globals_raw.split(","):
            entry = item.strip()
            if entry:
                globals_list.append((root / entry).resolve())

    return ProjectSection(
        name=name,
        version=version.strip() if version else None,
        store_dir=(root / store_dir).resolve(),
        globals=globals_list,
    )


def _build_targets(root: Path, lines: Iterable[str]) -> Dict[str, Target]:
    targets: Dict[str, Target] = {}
    for raw in lines:
        if raw.startswith("#"):
            continue
        parts = _split_target_fields(raw)
        if len(parts) != 5:
            raise ManifestError(
                f"Target line must contain five colon-separated fields: {raw!r}"
            )
        name, kind, deps_raw, executor, output_field = parts
        if not name:
            raise ManifestError(f"Target name missing in line: {raw!r}")
        if name in targets:
            raise ManifestError(f"Duplicate target {name!r}")
        if kind.lower() not in {"essential", "optional"}:
            raise ManifestError(
                f"Invalid kind {kind!r} for target {name!r} (expected essential|optional)"
            )
        deps = [d.strip() for d in deps_raw.split(",") if d.strip()] if deps_raw else []
        output_segments = [segment.strip() for segment in output_field.split(";") if segment.strip()]
        output_value = output_segments[0] if output_segments else ""
        metadata: Dict[str, str] = {}
        for extra in output_segments[1:]:
            if "=" not in extra:
                raise ManifestError(
                    f"Invalid metadata segment '{extra}' in target {name!r}; expected key=value"
                )
            key, value = extra.split("=", 1)
            metadata[key.strip().lower()] = value.strip()

        output_path = Path(output_value) if output_value else Path(name) / "worklog.md"
        success_path = metadata.get("success")
        success_path_obj = Path(success_path) if success_path else None

        targets[name] = Target(
            name=name,
            kind=kind.lower(),
            deps=deps,
            executor=executor or "agent",
            output=output_path,
            success=success_path_obj,
            metadata=metadata,
        )
    if not targets:
        raise ManifestError("No targets defined in [targets] section")
    return targets


def _split_target_fields(raw: str) -> List[str]:
    placeholder = "__PMAKE_COLON_PLACEHOLDER__"
    safe = raw.replace("://", placeholder)
    parts = [segment.strip() for segment in safe.split(":", 4)]
    if len(parts) != 5:
        raise ManifestError(
            f"Target line must contain five colon-separated fields: {raw!r}"
        )
    return [segment.replace(placeholder, "://") for segment in parts]


def _validate_targets(targets: Dict[str, Target]) -> None:
    # ensure optional targets are not dependencies
    optional_names: Set[str] = {t.name for t in targets.values() if t.is_optional()}
    for target in targets.values():
        for dep in target.deps:
            if dep in targets and dep in optional_names:
                raise ManifestError(
                    f"Target {target.name!r} depends on optional target {dep!r}; "
                    "optional targets cannot be prerequisites."
                )
            if dep in targets and dep == target.name:
                raise ManifestError(
                    f"Target {target.name!r} has self-dependency."
                )

    # ensure dependencies reference known targets or extern/skill URIs
    for target in targets.values():
        for dep in target.deps:
            if dep in targets:
                continue
            if dep.startswith("extern://") or dep.startswith("skill://"):
                continue
            raise ManifestError(
                f"Target {target.name!r} depends on unknown target or extern {dep!r}"
            )

    # detect cycles among manifest targets
    adjacency: Dict[str, List[str]] = defaultdict(list)
    for target in targets.values():
        for dep in target.deps:
            if dep in targets:
                adjacency[target.name].append(dep)

    visited: Set[str] = set()
    stack: Set[str] = set()

    def dfs(node: str) -> None:
        if node in stack:
            raise ManifestError(f"Dependency cycle detected at {node!r}")
        if node in visited:
            return
        stack.add(node)
        for dep in adjacency.get(node, []):
            dfs(dep)
        stack.remove(node)
        visited.add(node)

    for name in targets:
        dfs(name)
